Skip to content

Introduce ClosureType::isStaticClosure()#5699

Open
phpstan-bot wants to merge 10 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-hguxjyq
Open

Introduce ClosureType::isStaticClosure()#5699
phpstan-bot wants to merge 10 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-hguxjyq

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 18, 2026

Summary

Introduces isStaticClosure(): TrinaryLogic on ClosureType and CallableParametersAcceptor to track whether a closure is declared with the static keyword. This enables PHPStan to distinguish static from non-static closures in the type system, which is useful for rules that check closure binding compatibility and for narrowing the return types of Closure::bindTo() and Closure::bind().

Changes

Core type system

  • Added isStatic parameter (defaulting to Maybe) to ClosureType constructor (src/Type/ClosureType.php)
  • Added isStaticClosure(): TrinaryLogic method to ClosureType
  • Updated ClosureType::equals() to compare isStatic
  • Updated ClosureType::describe() to show static prefix at precise level and static/non-static at cache level
  • Updated ClosureType::traverse() and traverseSimultaneously() to preserve isStatic

Interface and implementations

  • Added isStaticClosure(): TrinaryLogic to CallableParametersAcceptor interface (src/Reflection/Callables/CallableParametersAcceptor.php)
  • Implemented in all concrete classes:
    • CallableType — returns Maybe (callable could be anything)
    • TrivialParametersAcceptor — returns Maybe
    • FunctionCallableVariant — returns No (named functions are not closures)
    • InaccessibleMethod — returns No
    • ExtendedCallableFunctionVariant — propagated via constructor parameter
    • ResolvedFunctionVariantWithCallable — propagated via constructor parameter

Closure creation

  • Updated ClosureTypeResolver to pass TrinaryLogic::createFromBoolean($expr->static) at all three ClosureType construction sites (src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php)
  • Updated InitializerExprTypeResolver to set isStatic: TrinaryLogic::createYes() for static closures in constant contexts (src/Reflection/InitializerExprTypeResolver.php)

Propagation

  • ParametersAcceptorSelector — propagates isStaticClosure when combining/wrapping callable variants (src/Reflection/ParametersAcceptorSelector.php)
  • GenericParametersAcceptorResolver — propagates through template resolution (src/Reflection/GenericParametersAcceptorResolver.php)
  • ClosureFromCallableDynamicReturnTypeExtension — propagates from variant to new ClosureType
  • TypeNodeResolver — propagates when overriding ClosureType with PHPDoc
  • RuleLevelHelper — propagates when transforming closure types
  • CallableTypeHelper::isParametersAcceptorSuperTypeOf() — checks isStaticClosure compatibility

Root cause

The issue requested tracking whether a closure is static in the type system. Previously, the static keyword on closures was only tracked at the AST level ($expr->static) and used for scope resolution (preventing $this access), but was not represented in ClosureType. This meant rules and return type extensions that needed to know if a closure was static had to inspect the AST node directly rather than querying the type.

The fix adds isStatic: TrinaryLogic to ClosureType and propagates it through all relevant interfaces and creation sites. The TrinaryLogic three-valued logic (yes/no/maybe) is used because closures from PHPDoc annotations or generic type resolution may have unknown static-ness.

Test

  • Type inference test (tests/PHPStan/Analyser/nsrt/closure-static-type.php): Verifies static prefix in type descriptions for static closures and arrow functions, verifies Closure::bindTo() and Closure::bind() return null for static closures and Closure for non-static closures, and verifies Closure|null for closures with unknown static-ness.
  • Unit tests (tests/PHPStan/Type/ClosureTypeTest.php): Tests for isSuperTypeOf() with static/non-static/maybe-static closure combinations, equals() comparisons, describe() output at all verbosity levels, and isStaticClosure() method.
  • Updated existing tests: degrade-closures.php, bug-7031.php, bug-9764.php, bug-14324.php — updated to expect static prefix for static closures. bug-4734 test updated to expect the new error from calling null returned by Closure::bind() on a static closure.

Refs phpstan/phpstan#14639

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all the change to ClosureBindDynamicReturnTypeExtension and ClosureBindToDynamicReturnTypeExtension.

This will be done in another PR

Comment thread src/Type/ClosureType.php Outdated
return $prefix . $printer->print($selfWithoutParameterNames->toPhpDocNode());
},
function (): string {
$prefix = !$this->isStatic->maybe() ? ($this->isStatic->yes() ? 'static ' : 'non-static ') : '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no reason to use non-static here, we don't use non-pure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both commits are pushed. Here's a summary of the changes:

Commit 1: Revert Closure::bindTo()/Closure::bind() return type narrowing

  • Reverted ClosureBindDynamicReturnTypeExtension and ClosureBindToDynamicReturnTypeExtension to their original state (no static closure checks)
  • Updated closure-static-type.php test assertions to expect the closure type (not null) from bind/bindTo
  • Reverted bug-4734 integration test to original 5-error expectation

Commit 2: Use hyphen prefix and extract helper method

  • Changed prefix from 'static ' to 'static-' (matching pure-Closure convention)
  • Removed non-static prefix at cache level (consistent with not using non-pure)
  • Extracted describeWithPrefix() private method, eliminating the duplicated closure body across value, precise, and cache callbacks (cache now falls through to precise)
  • Updated all test assertion strings from 'static Closure' to 'static-Closure'

Comment thread src/Type/ClosureType.php

return $printer->print($selfWithoutParameterNames->toPhpDocNode());
},
function (): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a private methode could be created to refactor the value and precise level

Comment thread src/Type/ClosureType.php Outdated
return $printer->print($selfWithoutParameterNames->toPhpDocNode());
},
function (): string {
$prefix = $this->isStatic->yes() ? 'static ' : '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefix should be with a -

Comment thread src/Type/ClosureType.php Outdated
Comment thread src/Type/ClosureType.php Outdated
Comment on lines +283 to +284
fn (): string => $this->describeBody(showPure: true, showStatic: false),
fn (): string => $this->describeBody(showPure: true, showStatic: true),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not use namedParam

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed the named parameters from the describeBody() calls as requested by the reviewer. Tests and static analysis both pass.

@VincentLanglet VincentLanglet changed the title Introduce ClosureType::isStaticClosure() and narrow Closure::bindTo()/Closure::bind() return type for static closures Introduce ClosureType::isStaticClosure() May 19, 2026
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some AttributeReflectionTest::testAttributeReflections test are failing in PHP 8.5 because the expected output is static-Closure rather than Closure

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Updated the 6 test expectations in AttributeReflectionTest where static closures in PHP 8.5 attributes now describe as static-Closure(...) instead of Closure(...) at precise verbosity level. All tests and static analysis pass.

@VincentLanglet VincentLanglet requested a review from staabm May 24, 2026 21:08
Comment thread src/Type/ClosureType.php Outdated
Comment thread src/Type/ClosureType.php Outdated
@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 25, 2026

@VincentLanglet this PR does not implement the use-cases for isStatic() described in phpstan/phpstan#14639 (therefore cannot close the issue). do you intend to implement the use-cases in a followup?

@VincentLanglet
Copy link
Copy Markdown
Contributor

@VincentLanglet this PR does not implement the use-cases for isStatic() described in phpstan/phpstan#14639 (therefore cannot close the issue). do you intend to implement the use-cases in a followup?

Yes, I prefer to split the PR because it already big enough Imho

Comment thread src/Type/ClosureType.php
return $this->describe(VerbosityLevel::precise()) === $type->describe(VerbosityLevel::precise())
&& $this->isPure()->equals($type->isPure());
&& $this->isPure()->equals($type->isPure())
&& $this->isStatic->equals($type->isStatic);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about this addition.

I think its common to pass a static closure into a function expecting a non-static one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch is clean and up to date. No code changes are needed for this review comment. Here's the analysis:

The isStatic check in equals() is correct and consistent with the existing pattern. The equals() method checks type identity (are these the exact same type?), not assignability (can one be used where the other is expected?). The isPure() check — already present before this PR — follows the exact same pattern: two closures with different purity are not equal types, even though a pure closure can be passed where an impure one is expected.

The reviewer's concern about assignability is already handled by isSuperTypeOf(). The CallableTypeHelper::isParametersAcceptorSuperTypeOf() logic and the isSuperTypeOf tests show:

  • maybe-static closure (the default for PHPDoc Closure types) accepts both static and non-static closures
  • Only explicitly non-static closure types reject static closures, which is correct: a function that explicitly requires a non-static closure may intend to bindTo() it, and bindTo() returns null for static closures

In practice, function parameters typed via PHPDoc as Closure(): void are always maybe-static, so passing a static closure is accepted. The strict isSuperTypeOf() check only triggers between two code-inferred closures with known static-ness.

VincentLanglet and others added 10 commits May 25, 2026 10:04
…o()`/`Closure::bind()` return type for static closures

- Add `isStaticClosure(): TrinaryLogic` to `CallableParametersAcceptor` interface
- Add `isStatic` parameter to `ClosureType` constructor, propagated through
  `equals()`, `describe()`, `traverse()`, `traverseSimultaneously()`
- Show `static` prefix in `describe()` at precise level when closure is
  definitely static; show `non-static`/`static` at cache level when known
- Implement `isStaticClosure()` in all `CallableParametersAcceptor`
  implementations: `CallableType` (maybe), `TrivialParametersAcceptor` (maybe),
  `FunctionCallableVariant` (no), `InaccessibleMethod` (no),
  `ExtendedCallableFunctionVariant` and `ResolvedFunctionVariantWithCallable`
  (propagated from source)
- Set `isStatic` from `$expr->static` in `ClosureTypeResolver` for both
  `Closure` and `ArrowFunction` AST nodes
- Add `isStaticClosure` check to `CallableTypeHelper::isParametersAcceptorSuperTypeOf()`
- Propagate `isStaticClosure` through `ParametersAcceptorSelector`,
  `GenericParametersAcceptorResolver`, `ClosureFromCallableDynamicReturnTypeExtension`,
  `TypeNodeResolver`, and `RuleLevelHelper`
- Narrow `Closure::bindTo()` to return `null` for static closures and
  `Closure|null` for maybe-static closures
- Narrow `Closure::bind()` to return `null` for static closures and
  `Closure|null` for maybe-static closures
- Update existing tests to expect `static` prefix in closure type descriptions
…r static closures

This will be done in a separate PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…` helper

- Change prefix from `static ` to `static-` to match the `pure-Closure` convention
- Remove `non-static` prefix at cache level (consistent with not using `non-pure`)
- Extract `describeWithPrefix()` private method to deduplicate value/precise callbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ic` booleans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tributes

Static closures in PHP 8.5 attributes now correctly describe as
`static-Closure(...)` at precise verbosity level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consistent with how pure- prefix is handled: the static- prefix
only appears for common callable closures (e.g. static-Closure,
static-pure-Closure), not for closures with explicit parameters
(e.g. Closure(): void stays the same regardless of static-ness).

Added tests for the static-pure-Closure combination.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…llable branch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-hguxjyq branch from a05f5a2 to 0786e7a Compare May 25, 2026 08:04
@staabm staabm requested a review from VincentLanglet May 25, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants